feat(imap): add repair-labels command - #754
Conversation
An add-only label merge (ReconcileMessageLabels with replace=false) never removes a label, and nothing later revisits a message unless its stored membership row changes again — a full mailbox enumeration is what used to clean this up, but PR kenn-io#699 made that expensive to run on every sync. repair-labels rebuilds a source's message_labels straight from imap_message_memberships on demand, reusing the same rebuild a full enumeration performs. Shaped like repair-encoding, repair-senders, repair-dates, and repair-list-ids: dry run by default, --apply to write, plus an optional identifier to scope to one source. Closes kenn-io#748. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhzbNgimfaBhPUSMzB8v2A
roborev: Combined Review (
|
A source's Identifier is its full imaps://user@host:993 connection string, not the email address a person would type — repair-labels' optional identifier argument matched only that, so the documented usage (an email address) silently matched zero sources and reported success. Use sourceops.ResolveExactOne, the same identifier-or-display- name resolver remove-account and repair-identity already use, and return the standard "no account found" error instead of going quiet. Found by roborev on PR kenn-io#754. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhzbNgimfaBhPUSMzB8v2A
roborev: Combined Review (
|
The repair took a context but never checked it: a long-running or cancelled request would still process every message, and — more subtly — the dry-run rollback sentinel returned as a nil error unconditionally, so a cancellation landing near the end of the loop would be reported as a completed dry run instead of surfaced as a cancellation. Checks ctx between messages and once more before the dry-run branch. Because database/sql rolls back a transaction in a background goroutine as soon as its context is cancelled, a query in flight can lose that race and surface a raw driver error instead of a clean ctx.Err() — normalized at the return boundary so callers always see ctx.Err() when the context is actually done, regardless of which statement the race landed on. Found by roborev on PR kenn-io#754. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhzbNgimfaBhPUSMzB8v2A
roborev: Combined Review (
|
…failure Each IMAP source repairs and commits independently, but the cache rebuild only ran after the whole multi-source loop returned without error. A later source failing — another source's repair, an output write, or a cancelled context — left every source repaired before it committed with no cache rebuild to follow, leaving the analytics cache stale with no signal to fix itself. Accumulate totals as each source commits, run the loop in a closure, and rebuild the cache unconditionally whenever --apply is set, independent of whether the loop itself returned an error — mirroring repair-identity's existing errors.Join(rerr, cacheErr) shape for the same situation. Found by roborev on PR kenn-io#754. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhzbNgimfaBhPUSMzB8v2A
roborev: Combined Review (
|
The repair's own ctx.Err() polling between messages (added for the previous roborev round) still left every individual statement running through tx.Query/tx.Exec, which use context.Background() internally — so a genuinely slow statement could not be interrupted mid-flight by the driver, only noticed after it returned. Threaded ctx through distinctIMAPMembershipMessageIDs, imapMembershipMailboxes, and ensureIMAPMailboxLabel's lookup path — the three call sites this touches (here and applyIMAPMailboxDeltas's own rebuild loop) both already carry ctx. Left ensureLabelWith and reconcileMessageLabelsTx on context.Background(): both are shared by public methods (EnsureLabel, EnsureLabelsBatch, ReconcileMessageLabels, AddMessageLabels) with no ctx in their own signatures, and threading ctx through them belongs to a broader change than this command. Found by roborev on PR kenn-io#754. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhzbNgimfaBhPUSMzB8v2A
roborev: Combined Review (
|
RepairIMAPSourceLabels polled ctx.Err() between messages, but the statements underneath ran on context.Background(), so a cancellation could not reach a query already in flight. ensureLabelWith and reconcileMessageLabelsTx are shared with EnsureLabel, EnsureLabelsBatch, ReconcileMessageLabels and AddMessageLabels, none of which have a context to give. Rather than change those four signatures, use the two patterns the store package already has for this: boundQuerier carries ctx into a querier-taking helper, and a Context sibling carries it through reconcileMessageLabelsTx while the old name keeps delegating with context.Background(). No public signature changes. Found by roborev on PR kenn-io#754. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HULapGKBxKDSNETGNhrGjG
roborev: Combined Review (
|
|
It reaches The two remaining outcomes are both safe. A newly inserted label is attached to the message by the reconcile below, which reports a change and bumps the revision. An existing row adopted by name changes only Reaching either outcome from the repair needs a mailbox whose label row is missing, which a completed sync does not leave behind. |
Closes #748
Adds
repair-labels, shaped likerepair-encoding,repair-senders,repair-dates, andrepair-list-ids. It rebuilds an IMAP source's messagelabels from its stored
imap_message_membershipsrows on demand — the samerebuild a full mailbox enumeration performs, without paying for one on every
sync.
It only rebuilds labels. A message with no membership rows never enters the
repair, so tombstoning stays a full-enumeration concern, and the default is
still a dry run.
On the 118,000-message Microsoft 365 account from #748, a manually reproduced
stray label (an add-only merge with no backing membership row) was found and
removed in a ~4s
--applyrun, leaving the message's real label untouched.No configuration or usage changes.